Skip to content

feat(ui): make stream framerate and bitrate configurable - #8

Open
alexander-clawthorne wants to merge 4 commits into
Producdevity:feat/geforce-nowfrom
alexander-clawthorne:feat/configurable-stream-quality
Open

alexander-clawthorne wants to merge 4 commits into
Producdevity:feat/geforce-nowfrom
alexander-clawthorne:feat/configurable-stream-quality

Conversation

@alexander-clawthorne

@alexander-clawthorne alexander-clawthorne commented Sep 29, 2026 •

Copy link
Copy Markdown

Following up on the Reddit thread where you said making these configurable made sense — the defaults are deliberately left alone so lower-end chips and wifi cards keep the behaviour they have now.

Based on feat/geforce-now, so it targets that branch rather than master.

What does this change?

Adds two rows to the settings screen, TARGET FRAMERATE (30/60) and MAX BITRATE (4/6/8/12/16/20 Mbps), so the GeForce NOW stream profile can be changed on device instead of being fixed at build time.

  • persistent_settings.zig — stores frames_per_second and max_bitrate_kbps with save/parse entries. Unknown keys are still skipped, so existing settings.tsv files load unchanged and remain readable by older builds.
  • handheld_ui.zig / handheld_ui.h — exposes both values to C through accessors mirroring the existing stream_width / stream_height pattern.
  • geforce_now/session_client.zig — reads the framerate when the client is created and uses it for stream-mode selection.
  • geforce_now/webrtc_session.zig — reads the bitrate ceiling at both request sites (requestBitrate and the NVST SDP answer), replacing the fixed maximum_bitrate_kbps constant, which is removed as it had no remaining references.
  • settings_view.zig — the two rows, plus row spacing adjusted from 48px to 46px so six rows fit above the footer.

Defaults are unchanged: 30 fps and 6000 kbps, matching the previous hard-coded values. Nothing changes for existing users unless they opt in. The Xbox path is untouched.

Two behaviours worth flagging for review:

  • Both values are negotiated at stream start, so a change applies to the next stream, not the running one. The settings screen states this.
  • sdp_protocol.zig derives the opening bid as max(4000, ceiling/4). With ceilings at or below 16000 that stays 4000 as before; at 20000 it becomes 5000. Happy to cap the choices at 16000 if you'd rather the initial request never move.

How did you test it?

Built with tools/bootstrap.sh && tools/zig.sh build release on an x86_64 Linux host and deployed to an Anbernic RG35XX-H (H700) running muOS 2508.2.

  • tools/zig.sh build test — passes
  • tools/zig.sh build fmt-check — passes
  • tools/zig.sh build release — passes

The 60 fps / 12 Mbps figures below come from a build carrying those values directly — the same profile the new settings select — captured with GREENOVERCAST_DEBUG=1 over several minutes of Control:

[118s] frames=7111/6852 source=1024x768 decoder=2 backpressure=0 decode_errors=0/0/0 queue=0/0
[119s] frames=7171/6912 source=1024x768 decoder=2 backpressure=0 decode_errors=0/0/0 queue=0/0
[120s] frames=7232/6972 source=1024x768 decoder=2 backpressure=0 decode_errors=0/0/0 queue=0/0

60 frames/second, cedar-h616 hardware decode, no decode errors, no backpressure, no queue growth. Also verified settings persist across restarts and that a pre-existing settings.tsv without the new keys still loads.

I have not tested on Rockchip/MPP hardware or the Miyoo Flip — only the H700 above.

Review follow-up (cubic)

All four cubic findings were valid. They're fixed as separate commits, following the focused fix(<scope>): pattern from #3:

  • 9e42628 fix(gfn): selectStreamMode overwrote the configured rate with the 30 fps constant and matched entitled resolutions against it. So in 52900e5, choosing 60 in settings still requested 30. The 60 fps log above came from a build with the value hard-coded, which skips that path. That's why it didn't show up. New test: stream mode selection keeps the configured frame rate (fails when the fix is reverted).
  • dbed9b6 fix(ui): persisted framerate/bitrate are only accepted when they're one of the offered choices; otherwise the default stays. The choice lists moved into persistent_settings.zig so the view and the parser share them. New test covers 0, 1 and u32 max.
  • bbf4d56 fix(gfn): the bitrate ceiling is clamped to 4000-20000 kbps once, before both the NVST answer and requestBitrate, and widened to u64 before the bps conversion.

Re-run after the fixes: build test, build fmt-check and build release all pass, and nm -D --undefined-only webrtc_stream | grep -c __aarch64_ is 0. The fixed build is deployed to the RG35XX-H with settings at 60 fps / 16 Mbps. Confirmed on device: the session requested 1024x768@60 and averaged 60.15 fps over about 4.5 minutes with no decode errors. Details and a log excerpt are in this comment.

AI disclosure

Per CONTRIBUTING: this was written with AI assistance (Claude). I reviewed the diff, built it, and verified the behaviour on hardware myself. Happy to adjust anything — naming, the bitrate choices, or the row ordering.


Summary by cubic

Makes GeForce NOW stream framerate and bitrate configurable from the settings screen, keeping the previous defaults of 30 FPS and 6 Mbps so existing behavior is unchanged.

  • Adds TARGET FRAMERATE (30/60 FPS) and MAX BITRATE (4/6/8/12/16/20 Mbps) rows to the settings screen.
  • Changes apply when the next stream starts; the running stream is unaffected.
  • The configured framerate now carries into stream mode selection instead of being reset to 30 FPS.
  • The bitrate ceiling is clamped to 4000-20000 kbps before negotiation.
  • Settings files without these keys, or with values outside the offered choices, fall back to the defaults.
  • Xbox streaming is untouched.

Written for commit bbf4d56. Summary will update on new commits.

Review in cubic

Adds TARGET FRAMERATE and MAX BITRATE rows to the settings screen so the
GeForce NOW stream profile can be changed on device instead of being fixed at
build time. Defaults are unchanged (30 fps, 6000 kbps), so behaviour on
lower-end chips and wifi cards is preserved unless the user opts in.

- persistent_settings: store frames_per_second and max_bitrate_kbps with
  save/parse entries; unknown keys are still skipped, so existing settings
  files load unchanged and stay readable by older builds
- handheld_ui: expose both values to C through accessors that mirror the
  existing stream_width/stream_height pattern
- geforce_now: read the framerate when the session client is created, and the
  bitrate ceiling at both request sites, replacing the fixed constant
- settings_view: two new rows, framerate toggling 30/60 and bitrate cycling
  4/6/8/12/16/20 Mbps

Both values are negotiated when a stream starts, so a change applies to the
next stream rather than the running one. The settings screen says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 5d0fea3f-af22-49e7-ba2a-fa613988a63f

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 6 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread src/ui/persistent_settings.zig Outdated
Comment thread src/provider/geforce_now/session_client.zig
Comment thread src/ui/persistent_settings.zig Outdated
Comment thread src/provider/geforce_now/webrtc_session.zig Outdated
selectStreamMode reset the stream rate to the hardcoded 30 fps and matched
entitled resolutions against that constant, so a 60 fps setting never
reached the session request. Track the configured rate separately and use
it for both the fallback and the resolution match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Only accept persisted framerate and bitrate values from the choices the
settings view offers; anything else keeps the default. The choice lists
now live in persistent_settings so the view and the parser share them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Clamp the configured ceiling to 4000-20000 kbps once and use it for both
the NVST answer and the bitrate request, widening before the bps
conversion so it cannot overflow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@alexander-clawthorne

alexander-clawthorne commented Sep 30, 2026 •

Copy link
Copy Markdown
Author

On-device result for bbf4d56: 60 fps now goes through the settings path.

Hardware and setup: RG35XX-H (H700), muOS. settings.tsv set to frames_per_second 60 / max_bitrate_kbps 16000. GFN Ultimate, CONTROL, about 4.5 minutes, GREENOVERCAST_DEBUG=1.

The line that was wrong before 9e42628:

GeForce NOW stream mode: 1024x768@60 for 640x480 display

From the per-second stats (274 samples, 1s-275s):

metric value
decoded fps (average) 60.15
per-second frames min / median / max 56 / 60 / 64 (no second below 55)
decoder cedar-h616 (decoder=2); v4l2-request probe unavailable, as expected
decode_errors / corrupt / gaps / missing / late_rtp all 0 for the whole session
backpressure 0
queue mostly 0/0. 8 brief spikes (peak 32/0 at 51s), each cleared by the next sample with no fps dip
audio 0 dropped, queued_ms=30 steady

The session ended at 275s because I saved and quit the game in CONTROL, so GeForce NOW sent a bye. The client logs that as GeForce NOW stream failed: SignalingClosed, then went back to the catalog normally. That's an expected shutdown, not a fault.

The bitrate ceiling isn't logged, so this run shows that 16000 was accepted and streamed fine. It doesn't show the ceiling being reached (the scene didn't need it).

Log excerpt (session ID and ICE addresses redacted; stats sampled every 30s)
GreenOvercast streaming client
Initializing display
SDL2 renderer ready: opengles2 (accelerated)
Controller active: Deeplay-keys (17 btn, 4 axes)
Audio: 48000 Hz stereo s16
Trying video decoder: v4l2-request
v4l2-request video decoder unavailable: no writable H.264 V4L2 Request decoder
Trying video decoder: cedar
Selected video decoder: cedar-h616
Lifecycle: cold_start -> authenticating
HTTP 200 https://pcs.geforcenow.com/v1/serviceUrls (5654 bytes)
HTTP 200 https://login.nvidia.com/token (1400 bytes)
Lifecycle: authenticating -> loading_catalog
HTTP 200 https://prod.cloudmatchbeta.nvidiagrid.net/v2/serverInfo (9203 bytes)
HTTP 200 https://games.geforce.com/graphql (77137 bytes)
Playable titles: 137
Lifecycle: loading_catalog -> catalog
Selected title: CONTROL Resonant (107016426)
Lifecycle: catalog -> provisioning
Creating session (107016426)
HTTP 200 https://login.nvidia.com/userinfo (417 bytes)
HTTP 200 https://prod.cloudmatchbeta.nvidiagrid.net/v2/serverInfo (9203 bytes)
HTTP 200 https://mes.geforcenow.com/v4/subscriptions?[redacted] (13188 bytes)
GeForce NOW membership: ULTIMATE, state: FULL, gameplay allowed: yes, entitled resolutions: 80
GeForce NOW stream mode: 1024x768@60 for 640x480 display
HTTP 200 https://prod.cloudmatchbeta.nvidiagrid.net/v2/serverInfo (9203 bytes)
GeForce NOW local streaming region selected
HTTP 200 https://us-illinois.cloudmatchbeta.nvidiagrid.net/v2/session?[redacted] (4187 bytes)
HTTP 200 https://us-illinois.cloudmatchbeta.nvidiagrid.net/v2/session/[session-id] (2320 bytes)
HTTP 200 https://us-illinois.cloudmatchbeta.nvidiagrid.net/v2/session/[session-id] (2320 bytes)
HTTP 200 https://us-illinois.cloudmatchbeta.nvidiagrid.net/v2/session/[session-id] (2320 bytes)
HTTP 200 https://us-illinois.cloudmatchbeta.nvidiagrid.net/v2/session/[session-id] (2320 bytes)
HTTP 200 https://us-illinois.cloudmatchbeta.nvidiagrid.net/v2/session/[session-id] (2714 bytes)
Lifecycle: provisioning -> signaling
GeForce NOW offer: 3948 bytes, H.264 PT 96, Opus PT 111
GeForce NOW audio track: mid 0
GeForce NOW video track: mid 1
Ignoring GeForce NOW audio track mic: mid 3
Lifecycle: signaling -> connecting
GeForce NOW transport connected: a=candidate:4 1 UDP 1846541567 [ip] 49744 typ prflx -> a=candidate:1467196637 1 UDP 2130569217 [ip] 19244 typ host
Lifecycle: connecting -> streaming
Streaming (hold Select + Start for 1s to exit)
GeForce NOW first audio RTP packet: 184 bytes
GeForce NOW first video RTP packet: 61 bytes
GeForce NOW input channel open
GeForce NOW input handshake: protocol 3
Video output active: 1024x768 format=23 decoder=cedar-h616
Created NV12 texture 1024x768
[1s] video_rtp=248 payload=247 rejected=0/pt-1 aus=58 frames=57/39 source=1024x768 nals=59/1/2/58 ts=87082 synced=1 gaps=0 missing=0 late_rtp=0 decoder=2 init_failures=1 fallbacks=0 backpressure=0 corrupt=0 info_changes=1 decode_errors=0/0/0 keyframes=1 queue=0/0
[1s] audio_rtp=105 decoded=103 dropped=0 late=2 pending=0 queued_ms=30 resets=0 keepalive=0/0
[2s] video_rtp=437 payload=436 rejected=0/pt-1 aus=118 frames=117/82 source=1024x768 nals=118/1/2/118 ts=175541 synced=1 gaps=0 missing=0 late_rtp=0 decoder=2 init_failures=1 fallbacks=0 backpressure=0 corrupt=0 info_changes=1 decode_errors=0/0/0 keyframes=1 queue=0/0
[2s] audio_rtp=206 decoded=200 dropped=0 late=3 pending=2 queued_ms=40 resets=0 keepalive=0/0
[3s] video_rtp=528 payload=527 rejected=0/pt-1 aus=178 frames=177/133 source=1024x768 nals=178/1/2/178 ts=265903 synced=1 gaps=0 missing=0 late_rtp=0 decoder=2 init_failures=1 fallbacks=0 backpressure=0 corrupt=0 info_changes=1 decode_errors=0/0/0 keyframes=1 queue=0/0
[3s] audio_rtp=307 decoded=302 dropped=0 late=4 pending=0 queued_ms=40 resets=0 keepalive=0/0
GeForce NOW gamepad input active: protocol 3, buttons 0x1
[30s] video_rtp=3921 payload=3920 rejected=0/pt-1 aus=1807 frames=1806/1716 source=1024x768 nals=1807/48/96/1807 ts=2712182 synced=1 gaps=0 missing=0 late_rtp=0 decoder=2 init_failures=1 fallbacks=0 backpressure=0 corrupt=0 info_changes=1 decode_errors=0/0/0 keyframes=1 queue=0/0
[30s] audio_rtp=3023 decoded=3013 dropped=0 late=10 pending=0 queued_ms=40 resets=0 keepalive=0/0
[60s] video_rtp=18220 payload=18219 rejected=0/pt-1 aus=3617 frames=3616/3505 source=1024x768 nals=3618/107/214/3617 ts=5428692 synced=1 gaps=0 missing=0 late_rtp=0 decoder=2 init_failures=1 fallbacks=0 backpressure=0 corrupt=0 info_changes=1 decode_errors=0/0/0 keyframes=1 queue=0/0
[60s] audio_rtp=6040 decoded=6026 dropped=0 late=13 pending=0 queued_ms=20 resets=0 keepalive=0/0
[90s] video_rtp=32858 payload=32857 rejected=0/pt-1 aus=5429 frames=5428/5304 source=1024x768 nals=5430/165/330/5429 ts=8146771 synced=1 gaps=0 missing=0 late_rtp=0 decoder=2 init_failures=1 fallbacks=0 backpressure=0 corrupt=0 info_changes=1 decode_errors=0/0/0 keyframes=1 queue=0/0
[90s] audio_rtp=9060 decoded=9040 dropped=0 late=20 pending=0 queued_ms=30 resets=0 keepalive=0/0
[120s] video_rtp=47477 payload=47476 rejected=0/pt-1 aus=7239 frames=7238/7100 source=1024x768 nals=7240/223/446/7239 ts=10861705 synced=1 gaps=0 missing=0 late_rtp=0 decoder=2 init_failures=1 fallbacks=0 backpressure=0 corrupt=0 info_changes=1 decode_errors=0/0/0 keyframes=1 queue=0/0
[120s] audio_rtp=12076 decoded=12050 dropped=0 late=26 pending=0 queued_ms=40 resets=0 keepalive=0/0
[150s] video_rtp=62260 payload=62259 rejected=0/pt-1 aus=9049 frames=9048/8894 source=1024x768 nals=9050/282/564/9049 ts=13576643 synced=1 gaps=0 missing=0 late_rtp=0 decoder=2 init_failures=1 fallbacks=0 backpressure=0 corrupt=0 info_changes=1 decode_errors=0/0/0 keyframes=1 queue=0/0
[150s] audio_rtp=15093 decoded=15061 dropped=0 late=32 pending=0 queued_ms=30 resets=0 keepalive=0/0
[180s] video_rtp=76539 payload=76538 rejected=0/pt-1 aus=10801 frames=10800/10636 source=1024x768 nals=10802/338/676/10801 ts=16204692 synced=1 gaps=0 missing=0 late_rtp=0 decoder=2 init_failures=1 fallbacks=0 backpressure=0 corrupt=0 info_changes=1 decode_errors=0/0/0 keyframes=1 queue=0/0
[180s] audio_rtp=18013 decoded=17974 dropped=0 late=38 pending=0 queued_ms=40 resets=0 keepalive=0/0
[210s] video_rtp=91230 payload=91229 rejected=0/pt-1 aus=12612 frames=12611/12432 source=1024x768 nals=12613/397/794/12612 ts=18921178 synced=1 gaps=0 missing=0 late_rtp=0 decoder=2 init_failures=1 fallbacks=0 backpressure=0 corrupt=0 info_changes=1 decode_errors=0/0/0 keyframes=1 queue=0/0
[210s] audio_rtp=21031 decoded=20986 dropped=0 late=44 pending=0 queued_ms=40 resets=0 keepalive=0/0
[240s] video_rtp=105999 payload=105998 rejected=0/pt-1 aus=14425 frames=14424/14226 source=1024x768 nals=14426/455/910/14425 ts=21640643 synced=1 gaps=0 missing=0 late_rtp=0 decoder=2 init_failures=1 fallbacks=0 backpressure=0 corrupt=0 info_changes=1 decode_errors=0/0/0 keyframes=1 queue=0/0
[240s] audio_rtp=24053 decoded=24003 dropped=0 late=48 pending=1 queued_ms=40 resets=0 keepalive=0/0
[270s] video_rtp=120237 payload=120236 rejected=0/pt-1 aus=16236 frames=16235/16009 source=1024x768 nals=16237/514/1028/16236 ts=24357202 synced=1 gaps=0 missing=0 late_rtp=0 decoder=2 init_failures=1 fallbacks=0 backpressure=0 corrupt=0 info_changes=1 decode_errors=0/0/0 keyframes=1 queue=0/0
[270s] audio_rtp=27071 decoded=27014 dropped=0 late=57 pending=0 queued_ms=40 resets=0 keepalive=0/0
[273s] video_rtp=121005 payload=121004 rejected=0/pt-1 aus=16417 frames=16416/16179 source=1024x768 nals=16418/519/1038/16417 ts=24628647 synced=1 gaps=0 missing=0 late_rtp=0 decoder=2 init_failures=1 fallbacks=0 backpressure=0 corrupt=0 info_changes=1 decode_errors=0/0/0 keyframes=1 queue=0/0
[273s] audio_rtp=27373 decoded=27313 dropped=0 late=60 pending=0 queued_ms=40 resets=0 keepalive=0/0
[274s] video_rtp=121399 payload=121399 rejected=0/pt-1 aus=16478 frames=16477/16240 source=1024x768 nals=16478/521/1042/16478 ts=24718672 synced=1 gaps=0 missing=0 late_rtp=0 decoder=2 init_failures=1 fallbacks=0 backpressure=0 corrupt=0 info_changes=1 decode_errors=0/0/0 keyframes=1 queue=0/0
[274s] audio_rtp=27474 decoded=27414 dropped=0 late=60 pending=0 queued_ms=30 resets=0 keepalive=0/0
[275s] video_rtp=121822 payload=121822 rejected=0/pt-1 aus=16538 frames=16537/16296 source=1024x768 nals=16538/523/1046/16538 ts=24808692 synced=1 gaps=0 missing=0 late_rtp=0 decoder=2 init_failures=1 fallbacks=0 backpressure=0 corrupt=0 info_changes=1 decode_errors=0/0/0 keyframes=1 queue=0/0
[275s] audio_rtp=27574 decoded=27514 dropped=0 late=60 pending=0 queued_ms=30 resets=0 keepalive=0/0
GeForce NOW stream failed: SignalingClosed
HTTP 200 https://us-illinois.cloudmatchbeta.nvidiagrid.net/v2/session/[session-id] (235 bytes)
H.264 bootstrap updated
Trying video decoder: v4l2-request
v4l2-request video decoder unavailable: no writable H.264 V4L2 Request decoder
Trying video decoder: cedar
Selected video decoder: cedar-h616
Lifecycle: streaming -> catalog
Catalog closed by controller back
Lifecycle: catalog -> shutdown
Cleanup

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant